iT邦幫忙

2025 iThome 鐵人賽

DAY 7
0
Modern Web

不只是登入畫面!一起打造現代化登入系統系列 第 7

房門與門鎖[ 4 / 6 ]:表單再升級 — reCAPTCHA 與 zxcvbn.js 驗證

  • 分享至 

  • xImage
  •  

在上一篇我們完成了登入頁的組件化設計,讓程式碼更易於維護。既然基礎結構已經打好,接下來就能專心加入更進階的功能了🥳。
這一篇,我們要把表單驗證的部分徹底完成,延續 房門與門鎖[ 2 / 6 ]:React 生態系導入 — 表單驗證 & Router 分頁 的簡易表單驗證,加入 reCAPTCHA 驗證zxcvbn.js 密碼強度檢測

完整的表單驗證

https://ithelp.ithome.com.tw/upload/images/20250922/20110586U2seAmev6n.png

本篇重點整理:

  • 機器人驗證: 簡單說明 reCAPTCHA,以及在專案中該如何引入與管理金鑰
  • 密碼強度驗證: 講述常見規則,引入 zxcvbn 實現更完整的密碼檢測
  • 程式碼實作: 提供部分程式碼的實作參考

reCAPTCHA

「我不是機器人🤖」這個詞是不是很熟悉呢?
這是 Google 開發的防機器人驗證功能,目前最常見的就是 reCAPTCHA v2 ( 核取方框 ),以及 v3 ( 隱形驗證 )

它主要解決了什麼問題?

  • 帳號安全 — 防止惡意機器人自動註冊帳號或暴力登入
  • 內容品質 — 避免垃圾留言與垃圾請求攻擊
  • 系統效能 — 減少伺服器的異常流量與資源消耗

我們立馬加入進來吧!

首先進入 Google reCAPTCHA 網站,登入後申請建立新網站:
https://ithelp.ithome.com.tw/upload/images/20250921/20110586QaoPf7yj15.png

  • 勾選 v2 的「我不是機器人」核取方塊
  • 網域名稱輸入自己的開發位址,例如本機測試就填 localhost

申請完成後會得到 Site Key (公開金鑰)Secret Key (秘密金鑰),兩者用途不同:

  • Site Key (公開金鑰) → 放在前端程式碼中,用來生成驗證框。
  • Secret Key (秘密金鑰) → 僅限後端使用,用來向 Google 驗證 token 是否有效。

為什麼 Key 不能公開?

如果把秘密金鑰放到前端或公開到 GitHub,會有以下風險:

  1. 惡意繞過驗證 — 攻擊者可以偽造驗證通過的結果,直接註冊假帳號或暴力登入。
  2. 濫用 API 配額 — 別人可以拿你的金鑰狂發驗證請求,消耗掉配額,造成服務異常。
  3. 拖累系統風評 — Google 可能因為異常流量,暫時封鎖你的金鑰,導致正常用戶無法使用。

金鑰該放哪裡?

常見做法:

  • 本地開發:放在 .env 檔案,例如:
RECAPTCHA_SECRET_KEY=your-key

並在 .gitignore 中忽略 .env,避免 push 到 GitHub。

  • 正式佈署:使用雲端平台 ( 如 Vercel、Netlify、Firebase ) 提供的 Environment Variables 來儲存,永遠不要硬寫進程式碼中。

🔰設定好 .env 後,別忘了要在 vite-env.d.ts 中設定型別哦!


密碼強度驗證

就算通過 reCAPTCHA 機器人驗證,如果使用者輸入一個「123456」當密碼,那系統依然非常危險⚠️。
因此登入/註冊頁還需要密碼強度驗證,在使用者輸入時立即提示密碼的安全等級。

常見的規則檢測包含:

  • 長度:通常建議至少 8–12 碼
  • 字母:必須包含大小寫字母
  • 數字:必須至少一個數字
  • 特殊字元:如 !@#$%^&*

這就是為什麼在之前的簡易驗證裡,會有像這樣的 Regex:

// validSchema.ts 部分程式碼
const passwordRegex = /^[a-zA-Z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?~]+$/;

開放特殊字元不會很危險嗎🤔?**

其實並不會。
允許使用特殊字元的目的是增加密碼組合的複雜度,讓暴力破解的可能性更低
真正需要注意的反而是:

  • SQL / XSS 注入風險:要靠後端處理輸入值(例如 escape 或使用參數化查詢)。
  • 資料庫安全性:密碼應該雜湊(bcrypt / argon2),而不是明文存放。

所以開放特殊字元是安全且推薦的做法,只要後端正確處理就沒問題。


zxcvbn.js — 更聰明的密碼檢測

基於上面的規則,我們當然可以自己寫 Regex 或條件判斷來做密碼驗證,但這種方法只能檢查 格式是否符合要求,卻無法判斷使用者輸入的密碼是不是常見弱密碼 ( 例如 password123 或 qwerty )。

這時候,我更推薦使用 Dropbox 開發的 zxcvbn
這個工具除了檢查密碼格式,還會:

  • 根據統計模型與字典庫,分析密碼是否容易被破解
  • 提供「破解所需時間」估算(如「幾分鐘」、「幾個世紀」)
  • 給出改善建議(例如「加入更多特殊符號」)

❗小缺點:改進建議目前只支援英文,對中文使用者來說可能不夠直觀,但整體功能仍然非常強大。

import zxcvbn from "zxcvbn";

const result = zxcvbn("MyP@ssw0rd!2025");

console.log(result.score); // 0~4,越高越安全
console.log(result.feedback.suggestions); // 改進建議

✅ 這樣一來,密碼強度檢測不僅僅是「格式驗證」,還能讓使用者更清楚了解自己的密碼到底安不安全。


部分程式碼
RecaptchaField.tsx

import ReCAPTCHA from "react-google-recaptcha";

interface RecaptchaFieldProps {
  onChange: (value: string | null) => void;
  error?: string;
}

export default function RecaptchaField({
  onChange,
  error,
}: RecaptchaFieldProps) {
  // 從環境變數中讀取 reCAPTCHA 網站金鑰
  const recaptchaSiteKey = import.meta.env.VITE_RECAPTCHA_PUBLIC_SITE_KEY;

  return (
    <div className="flex flex-col items-center">
      <ReCAPTCHA sitekey={recaptchaSiteKey} onChange={onChange} />
      {error && <p className="mt-1 text-red-600 text-xs">{error}</p>}
    </div>
  );
}

passwordStrengh.ts

import zxcvbn from "zxcvbn";

export type PasswordStrength =
  | "None"
  | "Weak"
  | "Moderate"
  | "Strong"
  | "VeryStrong";

interface PasswordStrengthResult {
  score: number;
  strength: PasswordStrength;
  barColor: string;
  textColor: string;
  text: string;
}

export const zxcvbnPS = (password: string): PasswordStrengthResult => {
  if (!password || password.length === 0) {
    return {
      score: -1, // 設定初始值小於 0
      strength: "None",
      barColor: "bg-gray-300 w-0",
      textColor: "text-gray-500",
      text: "",
    };
  }

  const result = zxcvbn(password);
  const score = result.score; // zxcvbn 分數範圍從 0 (worst) 到 4 (best)

  let strength: PasswordStrength;
  let barColor: string;
  let textColor: string;
  let text: string;

  switch (score) {
    case 0:
      strength = "Weak";
      barColor = "bg-red-500 w-1/5";
      textColor = "text-red-500";
      text = "非常弱";
      break;
    case 1:
      strength = "Weak";
      barColor = "bg-orange-500 w-2/5";
      textColor = "text-orange-500";
      text = "弱";
      break;
    case 2:
      strength = "Moderate";
      barColor = "bg-yellow-500 w-3/5";
      textColor = "text-yellow-500";
      text = "中等";
      break;
    case 3:
      strength = "Strong";
      barColor = "bg-lime-500 w-4/5";
      textColor = "text-lime-500";
      text = "強";
      break;
    case 4:
      strength = "VeryStrong";
      barColor = "bg-green-500 w-full";
      textColor = "text-green-500";
      text = "非常強";
      break;
    default:
      strength = "None";
      barColor = "bg-gray-300 w-0";
      textColor = "text-gray-500";
      text = "";
      break;
  }

  return {
    score,
    strength,
    barColor,
    textColor,
    text,
  };
};

RegisterForm.tsx

import { useForm } from "react-hook-form";
import { yupResolver } from "@hookform/resolvers/yup";
import { registerSchema, type RegisterInputs } from "../../utils/validSchema";

import InputField from "@components/ui/InputField";
import RecaptchaField from "@/components/ui/RecaptchaField";

export default function RegisterForm() {
  const {
    register,
    handleSubmit,
    setValue,
    formState: { errors },
    watch,
    trigger,
  } = useForm<RegisterInputs>({
    resolver: yupResolver(registerSchema),
    mode: "onBlur",
  });

  const passwordValue = watch("password");

  const onRecaptchaChange = (value: string | null) => {
    setValue("recaptcha", value ?? "");
    trigger("recaptcha");
  };

  const onSubmit = (data: RegisterInputs) => {
    console.log("表單提交資料:", data);
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="flex flex-col space-y-8">
      {/* 使用者名稱 */}
      <InputField
        id="username"
        label="使用者名稱"
        icon="user"
        type="text"
        autoComplete="username"
        register={register}
        errors={errors}
      />
      {/* 電子郵件 */}
      <InputField
        id="email"
        label="電子郵件"
        icon="envelope"
        type="email"
        autoComplete="email"
        register={register}
        errors={errors}
      />
      {/* 密碼 */}
      <InputField
        id="password"
        label="密碼"
        icon="lock"
        type="password"
        autoComplete="new-password"
        register={register}
        errors={errors}
        showStrength={true}
        passwordValue={passwordValue}
      />
      {/* 確認密碼 */}
      <InputField
        id="confirmPassword"
        label="確認密碼"
        icon="check"
        type="password"
        autoComplete="new-password"
        register={register}
        errors={errors}
        showPwdBtn={true}
      />

      {/* reCAPTCHA 核取方塊 */}
      <RecaptchaField
        onChange={onRecaptchaChange}
        error={errors.recaptcha?.message}
      />

      {/* 已接受服務條款 核取方塊 */}
      <div className="relative flex items-center space-x-2">
        <input
          type="checkbox"
          id="termsAccepted"
          {...register("termsAccepted")}
        />
        <label htmlFor="termsAccepted" className="text-gray-700">
          我已閱讀並同意{" "}
          <a href="/terms" className="text-primary-dark hover:underline">
            服務條款
          </a>
        </label>
        {errors.termsAccepted && (
          <p className="absolute mt-10 ml-2 text-red-500 text-xs">
            {errors.termsAccepted.message}
          </p>
        )}
      </div>

      {/* 註冊按鈕 */}
      <div className="flex justify-center">
        <button
          type="submit"
          className="w-3xs py-3 rounded-xl bg-primary-dark text-white text-xl font-bold hover:bg-primary focus:outline-none focus:ring-2 focus:ring-primary focus:ring-opacity-50 shadow-md transition duration-200"
        >
          註冊
        </button>
      </div>
    </form>
  );
}

參考資料:
Github Dropbox zxcvbn


上一篇
房門與門鎖[ 3 / 6 ]:模組化設計 — 拆分 components 實踐 DRY 策略
下一篇
房門與門鎖[ 5 / 6 ]:OAuth 實戰 — 用 Firebase 實作 Google 登入
系列文
不只是登入畫面!一起打造現代化登入系統12
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言